C++、Rust 编译一样糟糕?我用 1.7 万行代码试了试
【CSDN 编者按】编程语言界,在编译上,有两种语言比较出名,一个是老牌的 C++,一个是近几年因安全的性能而流行起来的 Rust,其都是被评为编译很慢的语言。那么这两种语言相较而言,究竟孰优孰劣,本文作者进行了测试,我们不妨通过其实验一探究竟。
原文链接:https://quick-lint-js.com/blog/cpp-vs-rust-build-times/
声明:本文为 CSDN 翻译,未经允许,禁止转载。
众所周知,C++ 的编译十分缓慢。编程圈子有一个著名的梗:“代码正在编译”,这个梗就来自 C++。
像 Google Chromium 这样的项目在最新的硬件上也需要一个小时才能构建完成,在旧硬件上则需要长达六个小时。文档里记载了数不清的加速编译的技巧,还有许多很容易出错的捷径,用来减少每次编译的代码量。即使使用数千美元的云计算,Chromium 的构建时间也需要几十分钟。我完全无法接受这一点。这样如何能正常工作?
Rust 也有类似的传言:编译时间是个大问题。但这真的是 Rust 的问题,还是黑 Rust 的谣言?跟 C++ 的编译时间相比,Rust 又如何呢?
我很关心编译速度和运行时性能。更快的构建测试循环可以提高生产力,而且可以让编程更快乐,那么我就能让软件的运行速度更快,客户也能更开心。所以,我决定亲眼看看 Rust 是否真的像他们说的那么差。我的计划如下:
找一个开源 C++ 项目;
将项目的某一部分单独分离出来变成一个小项目;
用 Rust 逐行重写 C++ 代码;
优化 C++ 项目和 Rust 项目的编译时间;
比较两个项目的编译和测试时间。
我的假设是(猜测,不是结论):
Rust 的代码行数比 C++ 略少。
因为C++中大多数函数和方法都需要定义两次(头文件中一次,实现中一次)。Rust 则不需要这样做,因此代码行数就会减少。
需要进行完整编译时,C++ 比 Rust 需要更多时间(即 Rust 胜出)。
这是因为 C++ 的 #include 和模板需要在每个 .cpp 中进行编译。虽然可以并行进行,但并行并不完美。
对于增量构建,Rust 的编译时间比 C++ 多(即 C++ 胜出)。
这是因为 Rust 一次编译一个 crate,而不像 C++ 那样一次编译一个文件,所以即使只有很小的变化,Rust 也要重新编译更多的代码。
你认为如何?我进行了一项调查:
42% 的人认为 C++ 会获胜,35% 的人认为需要具体分析,17% 的人认为 Rust 会获胜。
下面,实验开始!
寻找 C++ 和 Rust 的实验对象
寻找项目
如果需要花一个月来移植代码,我要移植哪个呢?我的挑选条件如下:
很少或没有第三方依赖(标准库没关系);
可以在 Linux 或 macOS 上运行(我不太关心在 Windows 上的编译时间);
有大量的测试用例(没有测试用例,我没办法知道我写的Rust代码是否正确);
涉及多种技术:FFI、指针、标准和自定义容器、工具类和函数、I/O、并发、泛型、宏、SIMD、继承。
最后的选择很简单:选我前几年写过的项目!我将移植之前在 quick-lint-js(https://quick-lint-js.com/blog/cpp-vs-rust-build-times/#:~:text=quick%2Dlint%2Djs%20project)项目中编写的 JavaScript 词法分析器。
修剪 C++ 代码
quick-link-js 的 C++ 部分包含大约 10 万行代码。我不会把这么多代码全都移植到 Rust,否则要花费一年时间!所以只选了 JavaScript 词法分析器部分。这需要涉及项目中的其他部分:
诊断系统
翻译系统(用于诊断)
多种内存分配器和容器(如 bump 分配器、适用于 SIMD 的字符串等)
多种工具函数(如 UTF-8 解码器、SIMD 封装等)
测试辅助代码(如自定义的断言宏)
C API
不幸的是,这个子集并不包含任何并发或 I/O 代码。也就是说,我没办法测试 Rust 的 async/await 在编译时间上的额外开销。不过在 quick-lint-js 中这种代码并不多,所以不是什么大问题。
首先,我复制了所有 C++ 代码,然后删掉了与词法分析器无关的东西,如语法分析器和 LSP 服务器等,直到无法删除任何代码为止。整个过程中都要保证 C++ 测试通过。
将 quick-lint-js 的代码精简到词法分析器(以及它所需的任何其他代码)之后,得到了大约 1.7 万行 C++ 代码:
重写
如何重写数千行 C++ 代码呢?只能一次重写一个文件。下面是具体的过程:
从某个模块着手;
复制代码和测试,用查找替换的方法修正某些语法,然后不断运行 cargo test,直到编译通过、测试通过;
如果需要先转换其他模块,则返回第二步对其进行转换,然后再回到该模块;
如果还有模块尚未转换,则返回第一步。
Rust 和 C++ 项目有一个主要区别可能会影响编译时间。在 C++ 项目中,诊断系统中包含许多代码生成、宏和 constexpr。而在 Rust 移植中,我采用了代码生成、proc 宏、普通的宏,还有一些 const。我听说 proc 宏很慢的原因只是它们很难写好。我希望我的 proc 宏写得还不错。
最后的 Rust 项目要比 C++ 项目略大一些。C++有 16,600 行代码,而 Rust 有 17,100 行。
优化 Rust 的编译时间
我很在意编译时间。因此,我的 C++ 项目已经针对编译时间做了许多优化。我需要针对 Rust 项目进行类似的优化。
我们来尝试一下以下手段,以优化 Rust 项目的编译时间:
更快的连接器
Cranelift 后端
编译器和连接器的标志
不同的工作区和测试布局
尽可能减少依赖特性
cargo-nextest
通过 PGO 定制的工具链
更快的连接器
第一步是对构建进行性能测试。首先通过 -Zself-profile 标志进行测试。在我的项目中,该标志会输出两个不同的文件。在其中一个文件中,run_linker 阶段的时间最长:
我曾经将连接器换成 mold linker,成功地改善了 C++ 的编译时间。我们在 Rust 项目上试试看:
很可惜,几乎看不到显著的改善。
上面是 Linux 的情况。macOS 也有另一个连接器:lld 和 zld。我们试试看:
在 macOS 上,换成另一种连接器也没有任何显著的改善。可能是因为 Linux 和 macOS 的默认连接器对于我的小项目来说已经非常优秀了。进一步优化的连接器(Mold、lld、zld)可能在大型项目上表现更好。
Cranelift 后端
我们再来看看 -Zself-profile 性能测试。对于另一个文件来说,LLVM_module_codegen_emit_obj 和 LLVM_passes 阶段时间最长:
我听说,除了默认的 rustc 后端 LLVM 之外,还有一个名为 Cranelift 的后端。我用 rustc Cranelift 后端尝试编译了一下,-Zself-profile 的结果很令人振奋:
但很可惜,使用 Cranelift 的实际编译时间甚至还不如 LLVM:
编译器和连接器选项
编译器有许多开关,可以加速编译(或减缓编译)。我们来尝试一部分:
-Zshare-generics=y (rustc) (实验性质的选项)
-Clink-args=-Wl,-s (rustc)
debug = false (Cargo)
debug-assertions = false (Cargo)
incremental = true and incremental = false (Cargo)
overflow-checks = false (Cargo)
panic = 'abort' (Cargo)
lib.doctest = false (Cargo)
lib.test = false (Cargo)
注意:quick, -Zshare-generics=y 相当于 quick, incremental=true 加上启用 -Zshare-generics=y 标志。其他条形图没有启用 -Zshare-generics=y,因为该选项仍不稳定(因此只能用仍在开发中的Rust编译器)。
大部分选项都有文档,但我没看到有人说过使用 -s 连接选项。-s 能删除调试信息,包括静态连接的 Rust 标准库中的调试信息。这就意味着连接器的工作量更少,从而能减少连接的时间。
工作区和测试布局
Rust 和 Cargo 对于文件的位置有一定的灵活性。该项目有三种合理的布局:
理论上,如果将代码分割到多个 crate 中,Cargo 就能并行调用 rustc。由于我的 Linux 机器有一个 32 线程的 CPU,macOS 机器有一个 10 线程 CPU,所以感觉启用并行应该能降低构建时间。
对于给定的 crate,Rust 项目中也有多个地方可以放置测试用例:
由于依赖循环,我没办法针对 tests 位于 src 内的布局进行测试。但我针对其他布局的各种组合进行了测试:
工作区配置(不论是分离的测试可执行文件(即多个测试用的exe文件)或合并成一个测试可执行文件(只有一个测试用的exe))似乎效果最好。所以我们后文采用工作区、多个测试可执行文件的配置。
尽可能减少依赖特性
许多 crate 支持可选的特性。有时,可选特性是默认启用的。我们用 cargo tree 看看启用了哪些特性:
libc crate 有一个特性名为 std。我们将其禁用并测试,看看构建时间是否有改善:
构建时间并没有任何提高。也许std特性并没有什么有意义的工作?
cargo-nextest
cargo-nextest工具宣称“相较于cargo test,速度最多可以提高60%”。我的Rust代码中有44%都是测试,也许cargo-nextest有用。我们来试试并比较一下构建和测试的时间。
在我的Linux机器上,cargo-nextest并没有改善,也没有变差。虽然输出结果漂亮了许多……
在macOS上会怎样呢?
在我的MacBookPro上,cargo-nextest的确快了那么一点点。不知道为什么加速跟操作系统有关。也许实际上跟硬件有关?
采用通过PGO定制的工具链
对于C++构建来说,我发现通过PGO(profile-guided optimizations,根据性能测试进行的优化,有时也称FDO)编译出的C++编译器,在性能上有很大提升。我们针对Rust工具链尝试一下PGO,然后再尝试用LLVM BOLT优化rustc,以及-Ctarget-cpu=native。
与C++编译器相比,似乎通过rustup发布的Rust工具链已经优化得很好了。PGO+BOLT带来的性能提升不到10%。但提升就是提升,所以接下来我们使用优化后的工具链与C++作比较。
优化C++构建
在原始的C++项目quick-lint-js上工作时,我已经使用常见的技术对其进行了优化,如PCH、禁用异常和RTTI、调整构建选项、删除无用的#include、将代码移出头文件、将模板实例化外置等。但C++有多种编译器和连接器。我们来比较一下它们,然后选择最好的一个跟Rust进行比较。
在Linux上,GCC显然是个异类。Clang要快得多。而我自己构建的Clang(与Rust构建一样,采用了PGO和BOLT)比Ubuntu自带的Clang又有很大提升。libstdc++构建平均而言比libc++快一点点。我们采用自己构建的Clang和libstd++,代表C++与Rust进行比较。
在macOS上,Xcode自带的Clang似乎比LLVM网站上提供的Clang工具链更好。我采用Xcode的Clang与Rust比较。
C++20模块
我的C++代码使用了#include。但C++20的import怎样呢?C++20的模块会让编译更快吗?
我在项目中尝试了C++20。截至目前,Linux上的CMake对于模块的支持仍然处于早期试验阶段,就连基本的helloworld都不能正常工作。
也许2023年C++20的模块会有长足发展。我非常希望如此,但至少目前,我只能用C++传统的#include。
C++和Rust的构建时间比较
我把C++项目移植到了Rust,并尽可能优化了Rust的构建时间。现在哪个编译器更快,C++还是Rust?
在我的Linux机器上,Rust构建有时候比C++快,但有时慢,或者不相上下。在incremental lex测试中(该测试修改的文件最大),Clang比rustc更快。但对于其他增量测试,rustc领先。
但是,在macOS上,结论完全不同。C++构建通常比Rust构建快得多。在incremental test-utf-8测试中(该测试修改的文件为中等大小),rustc编译得比Clang略快。但在所有其他增量测试以及完整构建中,Clang显然要快得多。
对于超过1.7万行的大项目
我只测试了1.7万行代码的小项目。对于大项目(比如10万行),构建时间如何呢?
为了测试C++和Rust编译器在大项目上的表现,我选择了最大的模块(词法分析器)并将其代码和测试用例复制了多个副本(8个、16个以及24个)。
由于我的性能测试也包括了运行测试的时间,所以我认为时间应该会线性增加。
Rust和Clang的编译时间都是线性增长的,符合我的预期。
对于C++而言,头文件(incremental diag-types)的变化对构建时间的影响最大,这一点符合预期。在其他增量测试中,构建时间增长的幅度较小,这要归功于Mold连接器。
我对Rust感到失望,即使在incremental test-utf-8测试中,rust的表现也不尽如人意(该测试添加了一些不相关的文件,因此不应该会受到太大影响)。该测试使用了工作区、多个测试exe文件,这意味着test-utf-8应该有自己的可执行文件,应该是单独编译的。
结论
Rust的编译时间是问题吗?是。有许多技巧可以加快构建,但我没找到任何方法能够带来数量级上的提升。
Rust的构建时间是否和C++一样差?是。对于大型项目,开发的编译时间甚至比C++更差,至少对于我的编程风格是这样。
回顾一下我的假设,可以看到假设的所有方面都错了:
Rust移植版本比C++版本的代码行数更多,而不是更少。
对于完整编译(1.7万行代码),C++消耗的时间基本上与Rust相同,甚至更少(10万+代码),而不会更多。
对于增量构建,Rust有时候消耗时间更短,有时更长(1.7万行代码),或者长得多(10万+代码),但也不一定。
是不是很失望?是的。在移植的过程中我学到了许多Rust的知识。例如,proc宏可以替换三种不同的代码生成器,从而简化构建流水线,也可以让贡献者更容易提交代码。我并不想念头文件,我也很感谢Rust的工具(特别是Cargo、rustup和miri)。
我决定不再移植quick-lint-js的其余部分到Rust。但如果构建时间能显著改善,也许我会改变主意。
《2022-2023 中国开发者大调查》重磅启动,欢迎扫描下方二维码,参与问卷调研,更有 iPad 等精美大礼等你拿!